﻿using System.Security.Cryptography;

namespace BCryptNet;

/// <summary>
/// BCrypt Enhanced (post v3.5)
/// Created to be compatible with other programming-language implementations of pre-hashed keys
/// i.e. passlib in python / php bcrypt and sha
/// </summary>
///  <para>
///         To hash a password using SHA384 pre-hashing for increased entropy see <see cref="BCryptExtendedV2.HashPassword(string, int, HashType)"/>
///  </para>
///  <code>string pw_hash = BCryptExtendedV2.HashPassword(plain_password);
///        (To validate an enhanced hash you can pass true as the last parameter of Verify or use  <see cref="BCryptExtendedV2.Verify(string, string, HashType)"/>)
///  </code>
[System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Overloads are for different purposes")]
public sealed class BCryptExtendedV2 : BCryptCore
{
    private const HashType DefaultEnhancedHashType = HashType.SHA384;

    /// <summary>
    ///  Pre-hash a password with SHA384 then using the OpenBSD BCrypt scheme and a salt generated by <see cref="BCryptCore.GenerateSalt(int,char)"/>.
    /// </summary>
    /// <param name="inputKey">The password to hash.</param>
    /// <param name="workFactor"></param>
    /// <param name="hashType"><seealso cref="HashType"/>HashType used (default SHA384)</param>
    /// <returns>The hashed password.</returns>
    /// <exception cref="SaltParseException">Thrown when the salt could not be parsed.</exception>
    public static string HashPassword(string inputKey, int workFactor = DefaultRounds,
        HashType hashType = DefaultEnhancedHashType) =>
        CreatePasswordHash(inputKey, GenerateSalt(workFactor), hashType,
            (s, type, version) => EnhancedHash(s, type, version));

    /// <summary>
    ///  Pre-hash a password with SHA384 then using the OpenBSD BCrypt scheme with a manually supplied salt/>.
    /// </summary>
    /// <remarks>
    ///  <para>You should generally leave generating salts to the library.</para>
    /// </remarks>
    /// <param name="inputKey">The password to hash.</param>
    /// <param name="salt"></param>
    /// <param name="hashType"><seealso cref="HashType"/>HashType used (default SHA384)</param>
    /// <returns>The hashed password.</returns>
    /// <exception cref="SaltParseException">Thrown when the salt could not be parsed.</exception>
    public static string HashPassword(string inputKey, string salt, HashType hashType = DefaultEnhancedHashType) =>
        CreatePasswordHash(inputKey, salt, hashType, (s, type, version) => EnhancedHash(s, type, version));

    /// <summary>
    /// Hashes key, base64 encodes before returning byte array
    /// </summary>
    /// <param name="inputString"></param>
    /// <param name="bcryptMinorRevision">(Default: 'a')</param>
    /// <param name="hashType"><seealso cref="HashType"/>HashType used (default SHA384)</param>
    /// <returns></returns>
    private static byte[] EnhancedHash(string inputString, HashType hashType, char bcryptMinorRevision = 'a')
    {
        switch (hashType)
        {
            case HashType.SHA256:
                using (var sha = SHA256.Create())
                    return SafeUTF8.GetBytes(Convert.ToBase64String(sha.ComputeHash(SafeUTF8.GetBytes(inputString))) +
                                             (bcryptMinorRevision >= 'a' ? Nul : EmptyString));
            case HashType.SHA384:
                using (var sha = SHA384.Create())
                    return SafeUTF8.GetBytes(Convert.ToBase64String(sha.ComputeHash(SafeUTF8.GetBytes(inputString))) +
                                             (bcryptMinorRevision >= 'a' ? Nul : EmptyString));
            case HashType.SHA512:
                using (var sha = SHA512.Create())
                    return SafeUTF8.GetBytes(Convert.ToBase64String(sha.ComputeHash(SafeUTF8.GetBytes(inputString))) +
                                             (bcryptMinorRevision >= 'a' ? Nul : EmptyString));
            default:
                throw new ArgumentOutOfRangeException(nameof(hashType), hashType, null);
        }
    }

    /// <summary>
    /// Compares the users stored hash with their password
    /// in a time-safe manner
    /// </summary>
    /// <param name="text"></param>
    /// <param name="hash"></param>
    /// <param name="hashType"><seealso cref="HashType"/>HashType used (default SHA384)</param>
    /// <returns></returns>
    public static bool Verify(string text, string hash, HashType hashType = DefaultEnhancedHashType)
    {
        return SecureEquals(SafeUTF8.GetBytes(hash),
            SafeUTF8.GetBytes(CreatePasswordHash(text, hash, hashType,
                (s, type, version) => EnhancedHash(s, type, version))));
    }

    /// <summary>
    /// Validate and Upgrade Hash
    /// </summary>
    /// <param name="currentKey"></param>
    /// <param name="currentHash"></param>
    /// <param name="newKey"></param>
    /// <param name="hashType"></param>
    /// <param name="workFactor"></param>
    /// <param name="forceWorkFactor"></param>
    /// <returns></returns>
    public static string ValidateAndUpgradeHash(string currentKey,
        string currentHash,
        string newKey,
        HashType hashType = DefaultEnhancedHashType,
        int workFactor = DefaultRounds,
        bool forceWorkFactor = false)
    {
        return ValidateAndUpgradeHash(currentKey, currentHash, DefaultEnhancedHashType, newKey, hashType, workFactor,
            forceWorkFactor);
    }

    /// <summary>
    /// Validate and Upgrade Hash
    /// </summary>
    /// <param name="currentKey"></param>
    /// <param name="currentHash"></param>
    /// <param name="currentKeyHashType"></param>
    /// <param name="newKey"></param>
    /// <param name="hashType"></param>
    /// <param name="workFactor"></param>
    /// <param name="forceWorkFactor"></param>
    /// <returns></returns>
    /// <exception cref="ArgumentNullException"></exception>
    /// <exception cref="ArgumentException"></exception>
    /// <exception cref="BcryptAuthenticationException"></exception>
    /// <exception cref="SaltParseException"></exception>
    public static string ValidateAndUpgradeHash(string currentKey,
        string currentHash,
        HashType currentKeyHashType,
        string newKey,
        HashType hashType = DefaultEnhancedHashType,
        int workFactor = DefaultRounds,
        bool forceWorkFactor = false)
    {
        if (currentKey == null)
            throw new ArgumentNullException(nameof(currentKey));

        if (string.IsNullOrEmpty(currentHash) || currentHash.Length != 60)
            throw new ArgumentException("Invalid Hash", nameof(currentHash));

        // Throw if validation fails (password isn't valid for hash)
        if (!Verify(currentKey, currentHash, currentKeyHashType))
            throw new BcryptAuthenticationException("Current credentials could not be authenticated");

        // Throw if invalid BCrypt Version
        if (currentHash[0] != '$' || currentHash[1] != '2')
            throw new SaltParseException("Invalid bcrypt version");

        // Throw if log rounds are out of range on hash, deals with custom salts
        if (workFactor < 1 || workFactor > 31)
            throw new SaltParseException("Work factor out of range");

        // Determine the starting offset and validate the salt
        int startingOffset = 3;

        if (currentHash[2] != '$')
        {
            char minor = currentHash[2];
            if (minor != 'a' && minor != 'b' && minor != 'x' && minor != 'y' || currentHash[3] != '$')
            {
                throw new SaltParseException("Invalid bcrypt revision");
            }

            startingOffset = 4;
        }

        // Extract number of rounds
        if (currentHash[startingOffset + 2] > '$')
        {
            throw new SaltParseException("Missing work factor");
        }

        // Extract details from salt
        int currentWorkFactor = Convert.ToInt16(currentHash.Substring(startingOffset, 2));

        // Never downgrade work-factor (unless forced)
        if (!forceWorkFactor && currentWorkFactor > workFactor)
        {
            workFactor = currentWorkFactor;
        }

        return CreatePasswordHash(newKey, GenerateSalt(workFactor), hashType,
            (s, type, version) => EnhancedHash(s, type, version));
    }
}
